Skip to main content

Structural Patterns

# Most engineers expect this proxy to "transparently" wrap MyService.
class MyService:
def work(self):
return "done"

def __repr__(self):
return "MyService()"

class LoggingProxy:
def __init__(self, service):
self._service = service

def __getattr__(self, name):
attr = getattr(self._service, name)
if callable(attr):
def wrapper(*args, **kwargs):
print(f"Calling {name}")
return attr(*args, **kwargs)
return wrapper
return attr

proxy = LoggingProxy(MyService())
print(proxy.work()) # "Calling work" then "done" ✓ works
print(repr(proxy)) # "<__main__.LoggingProxy object at 0x...>" ✗ WRONG
print(type(proxy).__name__) # "LoggingProxy" - NOT "MyService" ✗ WRONG

The surprise: __getattr__ is only called when normal attribute lookup has already failed. Python resolves special methods (__repr__, __len__, __iter__, __class__, etc.) by looking them up directly on the type, never on the instance, so __getattr__ is bypassed entirely. Your proxy silently breaks isinstance checks, repr(), len(), iteration, and every other protocol that uses dunder methods. You must explicitly delegate each dunder, or use __getattribute__ with care. This is the central gotcha in every Python proxy and the thread that connects all structural patterns: composition is powerful, but delegation has rules.

What You Will Learn

  • Why structural patterns exist and how to choose between them
  • Adapter - class adapter vs object adapter, unifying LLM clients
  • Bridge - decoupling two independent axes of variation
  • Composite - recursive trees with full Python protocol support (__iter__, __len__, __repr__)
  • Decorator pattern (the object-wrapping pattern, not @syntax) - composable HTTP client layers
  • Facade - hiding subsystem complexity behind a single clean API
  • Flyweight - sharing intrinsic state to cut memory, __slots__ synergy
  • Proxy - virtual proxy, protection proxy, remote proxy, and the __getattr__ / __getattribute__ trap in full detail
  • How to distinguish all seven patterns by the problem they solve
  • 5 interview Q&A with precise, senior-level answers

Prerequisites

  • Comfortable with Python classes, inheritance, and dunder methods
  • Familiar with abc.ABC and abstractmethod
  • Basic understanding of creational patterns (Module 1, Lesson 01)
  • Python 3.10+

Why Structural Patterns?

Structural patterns are about composition - how you assemble classes and objects into larger structures without coupling them tightly. Where creational patterns answer "how do I make this?", structural patterns answer "how do I connect and organise this so it stays maintainable as it grows?"

PatternCore ideaThe problem it fixes
AdapterTranslate one interface to anotherThird-party / legacy API mismatch
BridgeSeparate abstraction from implementationCombinatorial class explosion
CompositeTree of uniform objectsRecursive structures: files, UIs, pipelines
DecoratorWrap an object to extend behaviourFeature layering without subclassing
FacadeSingle entry point to a subsystemOverwhelming, multi-library APIs
FlyweightShare common state across many instancesMemory pressure from millions of small objects
ProxySurrogate that controls accessLazy load, access control, remote objects

A production Python service might use all seven simultaneously. A FastAPI inference service might use a Facade over external APIs, Proxy for lazy model loading, Decorator for retry/logging on HTTP clients, and Flyweight for NLP token objects.

Part 1 - Adapter

The Problem

You are building an AI platform. Three teams have integrated three different LLM providers, each with a different SDK design:

# OpenAI SDK style
import openai
response = openai.chat.completions.create(
model="gpt-4o",
messages=[{"role": "user", "content": "Hello"}]
)
text = response.choices[0].message.content

# Anthropic SDK style
import anthropic
client = anthropic.Anthropic()
response = client.messages.create(
model="claude-opus-4-6",
max_tokens=1024,
messages=[{"role": "user", "content": "Hello"}]
)
text = response.content[0].text

# Ollama local REST API style
import requests
response = requests.post("http://localhost:11434/api/chat", json={
"model": "llama3",
"messages": [{"role": "user", "content": "Hello"}],
"stream": False,
})
text = response.json()["message"]["content"]

Three calling conventions, three response shapes. Any code that needs to swap providers, run A/B tests, or fall back on failure is littered with if provider == "openai": ... branches. Testing requires mocking three different APIs.

Object Adapter - Wrapping the Adaptee

The object adapter holds a reference to the adaptee and translates calls. This is the idiomatic Python approach.

from __future__ import annotations
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import Any


# ── Target interface - the one ALL application code depends on ────────────────
class LLMClient(ABC):
"""Unified interface. Application code only knows this."""

@abstractmethod
def complete(self, prompt: str, *, max_tokens: int = 512) -> str:
...

@abstractmethod
def model_name(self) -> str:
...


# ── Adaptees (third-party SDKs - cannot be modified) ─────────────────────────
class _OpenAISDK:
"""Simulates the real openai library."""
def chat_completions_create(
self,
model: str,
messages: list[dict[str, str]],
max_tokens: int,
) -> dict[str, Any]:
return {"choices": [{"message": {"content": f"[OpenAI/{model}] response"}}]}


class _AnthropicSDK:
"""Simulates the real anthropic library."""
def messages_create(
self,
model: str,
messages: list[dict[str, str]],
max_tokens: int,
) -> dict[str, Any]:
return {"content": [{"text": f"[Anthropic/{model}] response"}]}


class _OllamaHTTP:
"""Simulates local Ollama REST API."""
def post(self, endpoint: str, payload: dict[str, Any]) -> dict[str, Any]:
model = payload.get("model", "unknown")
return {"message": {"content": f"[Ollama/{model}] response"}}


# ── Object Adapters - translate adaptee → target ──────────────────────────────
class OpenAIAdapter(LLMClient):
def __init__(self, model: str = "gpt-4o") -> None:
self._sdk = _OpenAISDK() # composition: HAS-A adaptee
self._model = model

def complete(self, prompt: str, *, max_tokens: int = 512) -> str:
raw = self._sdk.chat_completions_create(
model=self._model,
messages=[{"role": "user", "content": prompt}],
max_tokens=max_tokens,
)
return raw["choices"][0]["message"]["content"]

def model_name(self) -> str:
return self._model


class AnthropicAdapter(LLMClient):
def __init__(self, model: str = "claude-opus-4-6") -> None:
self._sdk = _AnthropicSDK()
self._model = model

def complete(self, prompt: str, *, max_tokens: int = 512) -> str:
raw = self._sdk.messages_create(
model=self._model,
messages=[{"role": "user", "content": prompt}],
max_tokens=max_tokens,
)
return raw["content"][0]["text"]

def model_name(self) -> str:
return self._model


class OllamaAdapter(LLMClient):
def __init__(
self,
model: str = "llama3",
base_url: str = "http://localhost:11434",
) -> None:
self._http = _OllamaHTTP()
self._model = model
self._base_url = base_url

def complete(self, prompt: str, *, max_tokens: int = 512) -> str:
raw = self._http.post(
f"{self._base_url}/api/chat",
{
"model": self._model,
"messages": [{"role": "user", "content": prompt}],
"stream": False,
},
)
return raw["message"]["content"]

def model_name(self) -> str:
return self._model

Application code is now fully provider-agnostic:

def run_pipeline(client: LLMClient, prompt: str) -> str:
print(f"Using: {client.model_name()}")
return client.complete(prompt)


clients: list[LLMClient] = [
OpenAIAdapter("gpt-4o"),
AnthropicAdapter("claude-opus-4-6"),
OllamaAdapter("llama3"),
]

for c in clients:
result = run_pipeline(c, "Explain transformers in one sentence.")
print(result)
print()

Swapping providers in tests is trivial - inject any LLMClient. This is also why the Adapter pattern makes production A/B testing and fallback routing clean:

class FallbackAdapter(LLMClient):
"""Tries primary; falls back to secondary on any exception."""

def __init__(self, primary: LLMClient, secondary: LLMClient) -> None:
self._primary = primary
self._secondary = secondary

def complete(self, prompt: str, *, max_tokens: int = 512) -> str:
try:
return self._primary.complete(prompt, max_tokens=max_tokens)
except Exception as exc:
print(f"[FALLBACK] Primary {self._primary.model_name()} failed: {exc}")
return self._secondary.complete(prompt, max_tokens=max_tokens)

def model_name(self) -> str:
return f"{self._primary.model_name()}{self._secondary.model_name()}"


resilient = FallbackAdapter(
primary=OpenAIAdapter("gpt-4o"),
secondary=OllamaAdapter("llama3"),
)
print(resilient.complete("Hello"))

Class Adapter - Using Multiple Inheritance

The class adapter uses multiple inheritance to simultaneously be an instance of both the target and the adaptee. Useful when you need access to protected/private methods of the adaptee.

class OpenAIClassAdapter(_OpenAISDK, LLMClient):
"""IS-A OpenAISDK and IS-A LLMClient simultaneously."""

def __init__(self, model: str = "gpt-4o") -> None:
self._model = model
# No _sdk attribute needed; we inherit the methods directly

def complete(self, prompt: str, *, max_tokens: int = 512) -> str:
# Calls inherited _OpenAISDK method directly
raw = self.chat_completions_create(
model=self._model,
messages=[{"role": "user", "content": prompt}],
max_tokens=max_tokens,
)
return raw["choices"][0]["message"]["content"]

def model_name(self) -> str:
return self._model


adapter = OpenAIClassAdapter()
print(adapter.complete("Hello")) # works as LLMClient
print(isinstance(adapter, _OpenAISDK)) # True - also IS the SDK

Object vs Class Adapter

DimensionObject AdapterClass Adapter
MechanismComposition (HAS-A adaptee)Multiple inheritance (IS-A adaptee)
Adapts subclasses?Yes - wrap any subclassNo - locked to one concrete adaptee
Access protected membersNoYes (inherited)
Runtime flexibilitySwap adaptee at runtimeFixed at class definition
Python idiomaticPreferred in most casesUse only when private access is needed
TestabilityEasy - inject mock adapteeHarder - inheritance is static

Rule of thumb: default to the object adapter. It follows composition-over-inheritance and lets you swap adaptees at runtime.

Part 2 - Bridge

The Problem

You need a notification system. You have notification channels (Email, SMS, Push) and message formats (plain text, HTML, JSON). The naive approach spawns a subclass per combination:

class PlainEmailNotifier: ...
class HTMLEmailNotifier: ...
class JSONEmailNotifier: ...
class PlainSMSNotifier: ...
class HTMLSMSNotifier: ...
class JSONSMSNotifier: ...
class PlainPushNotifier: ...
class HTMLPushNotifier: ...
class JSONPushNotifier: ...
# 3 channels × 3 formats = 9 classes already

Add a Slack channel: +3 classes. Add a Markdown format: +3 classes. At 5 × 5 you have 25 subclasses, most sharing identical formatting logic. This is the combinatorial explosion that Bridge prevents.

The Solution

Bridge splits the hierarchy into two independent dimensions:

  • Abstraction - what the client uses (Notifier, the "what")
  • Implementation - how the work gets done (MessageFormatter, the "how")

Each dimension grows independently.

from __future__ import annotations
from abc import ABC, abstractmethod
import json


# ── Implementation hierarchy - the "how" ─────────────────────────────────────
class MessageFormatter(ABC):
"""Implementor interface."""

@abstractmethod
def format(self, subject: str, body: str, metadata: dict) -> str:
...


class PlainTextFormatter(MessageFormatter):
def format(self, subject: str, body: str, metadata: dict) -> str:
return f"Subject: {subject}\n\n{body}"


class HTMLFormatter(MessageFormatter):
def format(self, subject: str, body: str, metadata: dict) -> str:
return (
f"<html><head><title>{subject}</title></head>"
f"<body><h1>{subject}</h1><p>{body}</p></body></html>"
)


class JSONFormatter(MessageFormatter):
def format(self, subject: str, body: str, metadata: dict) -> str:
return json.dumps({"subject": subject, "body": body, **metadata}, indent=2)


class MarkdownFormatter(MessageFormatter):
def format(self, subject: str, body: str, metadata: dict) -> str:
return f"# {subject}\n\n{body}"


# ── Abstraction hierarchy - the "what" ───────────────────────────────────────
class Notifier(ABC):
"""Abstraction - holds a reference to an implementation (the bridge)."""

def __init__(self, formatter: MessageFormatter) -> None:
self._formatter = formatter # ← this IS the bridge

@abstractmethod
def send(self, subject: str, body: str, recipient: str) -> None:
...

def _build_message(self, subject: str, body: str, metadata: dict) -> str:
return self._formatter.format(subject, body, metadata)


class EmailNotifier(Notifier):
def send(self, subject: str, body: str, recipient: str) -> None:
message = self._build_message(subject, body, {"to": recipient})
print(f"[EMAIL → {recipient}]\n{message}\n")


class SMSNotifier(Notifier):
def send(self, subject: str, body: str, recipient: str) -> None:
# SMS is length-limited: truncate body
message = self._build_message(subject, body[:160], {"channel": "sms"})
print(f"[SMS → {recipient}] {message[:160]}\n")


class PushNotifier(Notifier):
def send(self, subject: str, body: str, recipient: str) -> None:
message = self._build_message(subject, body, {"device_token": recipient})
print(f"[PUSH → {recipient}]\n{message}\n")


class SlackNotifier(Notifier):
def send(self, subject: str, body: str, recipient: str) -> None:
message = self._build_message(subject, body, {"channel": recipient})
print(f"[SLACK → {recipient}]\n{message}\n")

Now mix and match freely - N + M classes instead of N × M:

# At startup: wire up formatters
html_fmt = HTMLFormatter()
json_fmt = JSONFormatter()
plain_fmt = PlainTextFormatter()
md_fmt = MarkdownFormatter()

# Different channels with different formats - no new subclasses needed
email_html = EmailNotifier(html_fmt)
sms_plain = SMSNotifier(plain_fmt)
push_json = PushNotifier(json_fmt)
slack_md = SlackNotifier(md_fmt)

notification = {
"subject": "Your order shipped",
"body": "Your package will arrive by Friday.",
"recipient": "[email protected]",
}

for notifier in [email_html, sms_plain, push_json, slack_md]:
notifier.send(**notification)

Switching Implementations at Runtime

Because the formatter is stored as an attribute, you can swap it at runtime - something impossible with inheritance:

email = EmailNotifier(plain_fmt)
email.send("Hello", "Plain body", "[email protected]")

# Switch to HTML for premium users
email._formatter = html_fmt
email.send("Hello", "HTML body", "[email protected]")

Bridge vs Adapter

DimensionBridgeAdapter
Design timeDesigned upfront to vary independentlyRetrofitted to fix incompatibility
IntentEnable independent variation of two axesMake one interface look like another
Controls both sidesYesOnly the adapter side
RelationshipAbstraction uses implementationAdapter wraps adaptee
Class countN + MOne adapter per adaptee

Part 3 - Composite

The Problem

File systems, ML pipeline DAGs, permission groups, and UI component trees all share one structure: leaves and containers are treated uniformly. Without Composite, client code checks isinstance at every recursive call:

def total_size(node):
if isinstance(node, File): # leaf case
return node.size_bytes
elif isinstance(node, Directory): # container case
return sum(total_size(child) for child in node.children)
else:
raise TypeError(f"Unknown node: {type(node)}")

This breaks open/closed principle: adding a SymLink type forces you to update total_size everywhere.

The Solution

Define a common Component interface. Both leaves (File) and composites (Directory) implement it. Composites delegate to their children recursively.

from __future__ import annotations
from abc import ABC, abstractmethod
from typing import Iterator


class FileSystemComponent(ABC):
"""Uniform interface for both files and directories."""

def __init__(self, name: str) -> None:
self.name = name

@abstractmethod
def size(self) -> int:
"""Total size in bytes."""
...

@abstractmethod
def __iter__(self) -> Iterator[FileSystemComponent]:
"""Iterate over all nodes in the subtree, including self."""
...

@abstractmethod
def __repr__(self) -> str:
...

def __len__(self) -> int:
"""Total number of nodes in the subtree (including self)."""
return sum(1 for _ in self)


# ── Leaf ──────────────────────────────────────────────────────────────────────
class File(FileSystemComponent):
def __init__(self, name: str, size_bytes: int) -> None:
super().__init__(name)
self._size = size_bytes

def size(self) -> int:
return self._size

def __iter__(self) -> Iterator[FileSystemComponent]:
yield self # A file is a single-element "tree"

def __repr__(self) -> str:
return f"File({self.name!r}, {self._size:,}B)"


# ── Composite ─────────────────────────────────────────────────────────────────
class Directory(FileSystemComponent):
def __init__(self, name: str) -> None:
super().__init__(name)
self._children: list[FileSystemComponent] = []

def add(self, component: FileSystemComponent) -> Directory:
self._children.append(component)
return self # fluent interface

def remove(self, component: FileSystemComponent) -> None:
self._children.remove(component)

def size(self) -> int:
return sum(child.size() for child in self._children)

def __iter__(self) -> Iterator[FileSystemComponent]:
yield self
for child in self._children:
yield from child # recursive delegation

def __repr__(self) -> str:
return f"Directory({self.name!r}, {len(self._children)} children)"


# ── Build a tree ──────────────────────────────────────────────────────────────
root = Directory("project")
src = Directory("src")
tests = Directory("tests")

src.add(File("main.py", 4_200)).add(File("utils.py", 1_800))
tests.add(File("test_main.py", 900)).add(File("conftest.py", 300))
root.add(src).add(tests).add(File("README.md", 512))

print(f"Total size: {root.size():,} bytes") # 7,712 bytes
print(f"Total nodes: {len(root)}") # 8 (3 dirs + 5 files)
print()

# Client code treats leaves and composites uniformly
for component in root:
prefix = " " if isinstance(component, File) else ""
print(f"{prefix}{component!r}")

ML Pipeline Composite

The same pattern models ML pipeline stages where stages can be nested groups:

from __future__ import annotations
from abc import ABC, abstractmethod
from typing import Any


class PipelineStage(ABC):
def __init__(self, name: str) -> None:
self.name = name

@abstractmethod
def run(self, data: Any) -> Any:
...

def __repr__(self) -> str:
return f"{type(self).__name__}({self.name!r})"


class TransformStage(PipelineStage):
def __init__(self, name: str, fn) -> None:
super().__init__(name)
self._fn = fn

def run(self, data: Any) -> Any:
print(f" [{self.name}]")
return self._fn(data)


class PipelineGroup(PipelineStage):
"""Composite - runs children sequentially, passing output as next input."""

def __init__(self, name: str) -> None:
super().__init__(name)
self._stages: list[PipelineStage] = []

def add(self, stage: PipelineStage) -> PipelineGroup:
self._stages.append(stage)
return self

def run(self, data: Any) -> Any:
print(f"[Group: {self.name}]")
for stage in self._stages:
data = stage.run(data)
return data


# Nested groups work because PipelineGroup IS-A PipelineStage
preprocessing = (
PipelineGroup("preprocessing")
.add(TransformStage("tokenize", str.split))
.add(TransformStage("lowercase", lambda tokens: [t.lower() for t in tokens]))
.add(TransformStage("strip_punct", lambda tokens: [t.strip(".,!?") for t in tokens]))
)

full_pipeline = (
PipelineGroup("inference_pipeline")
.add(preprocessing) # composite inside composite
.add(TransformStage("embed", lambda tokens: f"<embedding of {len(tokens)} tokens>"))
.add(TransformStage("classify", lambda emb: {"label": "positive", "input": emb}))
)

result = full_pipeline.run("Hello, World! This is Python.")
print(result)

Composite Protocol Implementation Checklist

MethodResponsibilityLeaf behaviourComposite behaviour
size() / count()Aggregate metricReturn own valueSum over children
__iter__Flat iteration over all nodesyield selfyield self; yield from child
__len__Total node count1Derived from __iter__
__repr__Debug displayShow name + dataShow name + child count
add() / remove()Manage childrenRaise TypeErrorAppend / remove

Part 4 - Decorator Pattern

Critical Distinction: Pattern vs Syntax

Python's @decorator syntax wraps callables at definition time. The GoF Decorator pattern wraps object instances at runtime, with both wrapper and wrapped implementing the same interface. They share an idea but are not the same thing.

# Python @syntax - wraps a FUNCTION, returns a new callable
def logged(fn):
def wrapper(*args, **kwargs):
print(f"Calling {fn.__name__}")
return fn(*args, **kwargs)
return wrapper

@logged
def process(x): return x * 2 # process is now wrapper

# GoF Decorator pattern - wraps an OBJECT, same interface
class LoggedHTTPClient: # implements HTTPClient
def __init__(self, client): # wraps another HTTPClient
self._client = client

def get(self, url): # delegates + extends
print(f"GET {url}")
return self._client.get(url)

The GoF version is what allows stacking - wrapping a wrapper which wraps a wrapper, all transparently via a shared interface.

The Problem

You have an HTTPClient. Different callers need different cross-cutting behaviours:

  • Team A: logging only
  • Team B: retry on 5xx only
  • Team C: rate limiting only
  • Team D: all three, in a specific order

With subclassing you need: LoggedClient, RetryClient, RateLimitedClient, LoggedRetryClient, LoggedRateLimitedClient, RetryRateLimitedClient, LoggedRetryRateLimitedClient - seven subclasses for three features.

The Solution - Composable Object Decorators

from __future__ import annotations
from abc import ABC, abstractmethod
from dataclasses import dataclass
from time import sleep, monotonic
import random


@dataclass
class Response:
status: int
body: str


class HTTPClient(ABC):
@abstractmethod
def get(self, url: str) -> Response: ...

@abstractmethod
def post(self, url: str, data: dict) -> Response: ...


# ── Concrete implementation ───────────────────────────────────────────────────
class RealHTTPClient(HTTPClient):
def get(self, url: str) -> Response:
# Simulate 30% failure rate
if random.random() < 0.3:
return Response(500, "Internal Server Error")
return Response(200, f"GET {url} body")

def post(self, url: str, data: dict) -> Response:
return Response(201, f"POST {url} created")


# ── Base decorator - transparent delegation ───────────────────────────────────
class HTTPClientDecorator(HTTPClient):
"""
Base class for all decorators. Override only what you need;
everything else passes through unchanged.
"""
def __init__(self, wrapped: HTTPClient) -> None:
self._wrapped = wrapped

def get(self, url: str) -> Response:
return self._wrapped.get(url)

def post(self, url: str, data: dict) -> Response:
return self._wrapped.post(url, data)


# ── Concrete decorators ───────────────────────────────────────────────────────
class LoggingDecorator(HTTPClientDecorator):
def get(self, url: str) -> Response:
print(f"[LOG] --> GET {url}")
response = super().get(url)
print(f"[LOG] <-- {response.status}")
return response

def post(self, url: str, data: dict) -> Response:
print(f"[LOG] --> POST {url} {data}")
response = super().post(url, data)
print(f"[LOG] <-- {response.status}")
return response


class RetryDecorator(HTTPClientDecorator):
def __init__(
self,
wrapped: HTTPClient,
max_retries: int = 3,
backoff_base: float = 0.05,
) -> None:
super().__init__(wrapped)
self._max_retries = max_retries
self._backoff_base = backoff_base

def get(self, url: str) -> Response:
response = Response(500, "")
for attempt in range(self._max_retries):
response = super().get(url)
if response.status < 500:
return response
wait = self._backoff_base * (2 ** attempt)
print(f"[RETRY] Attempt {attempt + 1}/{self._max_retries} failed ({response.status}), wait {wait:.2f}s")
sleep(wait)
return response

def post(self, url: str, data: dict) -> Response:
# POST is not idempotent - do not retry
return super().post(url, data)


class RateLimitDecorator(HTTPClientDecorator):
def __init__(self, wrapped: HTTPClient, calls_per_second: float = 5.0) -> None:
super().__init__(wrapped)
self._min_interval = 1.0 / calls_per_second
self._last_call: float = 0.0

def _throttle(self) -> None:
elapsed = monotonic() - self._last_call
if elapsed < self._min_interval:
sleep(self._min_interval - elapsed)
self._last_call = monotonic()

def get(self, url: str) -> Response:
self._throttle()
return super().get(url)

def post(self, url: str, data: dict) -> Response:
self._throttle()
return super().post(url, data)


# ── Compose decorators ────────────────────────────────────────────────────────
# Read inside-out: RateLimit is innermost, Logging is outermost
client: HTTPClient = LoggingDecorator(
RetryDecorator(
RateLimitDecorator(
RealHTTPClient(),
calls_per_second=10.0,
),
max_retries=3,
)
)

response = client.get("https://api.example.com/users")
print(response)

Execution Order Matters

Decorators execute in the order they are stacked. The call passes inward through each layer and the response passes outward:

client.get("url")
LoggingDecorator.get() → logs "GET url"
RetryDecorator.get() → tries up to 3 times
RateLimitDecorator.get() → throttles
RealHTTPClient.get() → makes actual request
RateLimitDecorator returns
RetryDecorator: if 5xx, retry; else return
LoggingDecorator logs response status
client receives response

If you want to log each retry attempt, nest Logging inside Retry. If you want only the final outcome logged, keep Logging outside. This fine control is impossible with static inheritance.

Composing with a Factory Function

For teams that find the nested constructor calls hard to read, a factory function is clean:

def build_client(
base_url: str,
*,
logging: bool = True,
retries: int = 3,
rate_limit: float = 5.0,
) -> HTTPClient:
client: HTTPClient = RealHTTPClient()
client = RateLimitDecorator(client, calls_per_second=rate_limit)
client = RetryDecorator(client, max_retries=retries)
if logging:
client = LoggingDecorator(client)
return client


production_client = build_client("https://api.example.com", retries=5)
test_client = build_client("http://localhost:8001", logging=False, rate_limit=100.0)

Part 5 - Facade

The Problem

A document intelligence pipeline needs to:

  1. Extract text from PDFs (pdfplumber)
  2. Run OCR on scanned pages (pytesseract)
  3. Detect the document's language (langdetect)
  4. Generate a semantic embedding (sentence-transformers)

Each library has its own API style, error handling, and configuration surface. A controller function that coordinates all four quickly becomes a 200-line tangle of library-specific logic. Every caller has to know all four APIs.

The Solution

from __future__ import annotations
from dataclasses import dataclass, field
from pathlib import Path


# ── Subsystem components (simulating real library wrappers) ───────────────────
class PDFExtractor:
"""Thin wrapper around pdfplumber."""

def extract_text(self, path: Path) -> str:
print(f"[PDF] Extracting text from {path.name}")
# Real: with pdfplumber.open(path) as pdf: ...
return "The attention mechanism allows models to focus on relevant parts of the input."

def page_count(self, path: Path) -> int:
# Real: with pdfplumber.open(path) as pdf: return len(pdf.pages)
return 8

def get_page_image(self, path: Path, page: int) -> bytes:
# Real: convert page to image bytes for OCR
return b"<image bytes>"


class OCREngine:
"""Thin wrapper around pytesseract."""

def ocr_image(self, image_bytes: bytes) -> str:
print("[OCR] Running Tesseract on page image")
return "Text recovered via OCR from scanned page."

def needs_ocr(self, text: str) -> bool:
# Heuristic: extracted text suspiciously short → likely a scanned page
return len(text.strip()) < 80


class LanguageDetector:
"""Thin wrapper around langdetect."""

def detect(self, text: str) -> str:
print("[LANG] Detecting language")
# Real: return langdetect.detect(text)
return "en"

def detect_with_confidence(self, text: str) -> tuple[str, float]:
# Real: from langdetect import detect_langs; ...
return ("en", 0.997)


class EmbeddingModel:
"""Thin wrapper around sentence-transformers."""

def __init__(self, model_name: str = "all-MiniLM-L6-v2") -> None:
print(f"[EMBED] Loading model '{model_name}'...")
self._model_name = model_name
# Real: self._model = SentenceTransformer(model_name)
print("[EMBED] Ready.")

def encode(self, text: str) -> list[float]:
print(f"[EMBED] Encoding {len(text)} characters")
# Real: return self._model.encode(text).tolist()
return [round(hash(text[i:i+2]) % 1000 / 1000, 4) for i in range(0, min(len(text), 384), 2)]


# ── Result dataclass ──────────────────────────────────────────────────────────
@dataclass
class ProcessedDocument:
source_path: str
text: str
language: str
language_confidence: float
embedding: list[float]
page_count: int
ocr_applied: bool
word_count: int = field(init=False)

def __post_init__(self) -> None:
self.word_count = len(self.text.split())

def summary(self) -> str:
return (
f"Path: {self.source_path}\n"
f"Pages: {self.page_count}\n"
f"Words: {self.word_count:,}\n"
f"Language: {self.language} ({self.language_confidence:.1%})\n"
f"OCR applied: {self.ocr_applied}\n"
f"Embedding: {len(self.embedding)} dims"
)


# ── The Facade ────────────────────────────────────────────────────────────────
class DocumentProcessor:
"""
Facade over PDF extraction, OCR, language detection, and embedding.

Callers see one method: process(path) → ProcessedDocument.
All subsystem complexity is hidden.
"""

def __init__(self, embedding_model: str = "all-MiniLM-L6-v2") -> None:
# Facade coordinates (and owns) its subsystems
self._pdf = PDFExtractor()
self._ocr = OCREngine()
self._lang = LanguageDetector()
self._embed = EmbeddingModel(embedding_model)

def process(self, path: str | Path) -> ProcessedDocument:
path = Path(path)
if path.suffix.lower() != ".pdf":
raise ValueError(f"Expected a .pdf file, got: {path.suffix!r}")

# Step 1: Extract text
text = self._pdf.extract_text(path)
page_count = self._pdf.page_count(path)
ocr_applied = False

# Step 2: OCR fallback for scanned / low-text pages
if self._ocr.needs_ocr(text):
print("[FACADE] Short text detected - attempting OCR fallback")
image_bytes = self._pdf.get_page_image(path, page=0)
ocr_text = self._ocr.ocr_image(image_bytes)
if ocr_text:
text = ocr_text
ocr_applied = True

# Step 3: Language detection
language, confidence = self._lang.detect_with_confidence(text)

# Step 4: Semantic embedding
embedding = self._embed.encode(text)

return ProcessedDocument(
source_path=str(path),
text=text,
language=language,
language_confidence=confidence,
embedding=embedding,
page_count=page_count,
ocr_applied=ocr_applied,
)

def batch_process(self, paths: list[str | Path]) -> list[ProcessedDocument]:
"""Process multiple documents, collecting errors without stopping."""
results = []
for path in paths:
try:
results.append(self.process(path))
except Exception as exc:
print(f"[FACADE] Skipping {path}: {exc}")
return results


# ── Usage - caller knows nothing about pdfplumber, pytesseract, etc. ──────────
processor = DocumentProcessor("all-MiniLM-L6-v2")
doc = processor.process("attention_is_all_you_need.pdf")
print(doc.summary())

Keeping the Facade Thin

The Facade can become an anti-pattern (a "god class") if it:

  • Absorbs business logic instead of delegating to subsystems
  • Hides configurability that power users legitimately need
  • Groups unrelated subsystems just because someone wanted "one class"

The fix: expose subsystems for power users while keeping the Facade for common cases.

# Power users can bypass the Facade and access subsystems directly
extractor = PDFExtractor()
raw_text = extractor.extract_text(Path("doc.pdf"))

# Common users get the clean API
processor = DocumentProcessor()
doc = processor.process("doc.pdf")

Facade vs Adapter

FacadeAdapter
Number of classes wrappedMany (a whole subsystem)One (a single adaptee)
IntentSimplificationCompatibility
New interfaceSimplified, purpose-builtMatches an existing target interface
Both sides you controlUsually yesOften no (adaptee is third-party)

Part 6 - Flyweight

The Problem

An NLP pipeline processes millions of documents. Each document contains thousands of tokens. Naively, each token occurrence is a full Python object with its string, POS tag, and lemma stored per instance - even though "the" appears ten million times with the same POS tag and lemma every time.

# Naive - new object per occurrence
from dataclasses import dataclass

@dataclass
class Token:
text: str
pos: str # Part-of-speech, same for all "the"s
lemma: str # Lemmatized form, same for all "the"s
doc_id: int
position: int

# 10 million occurrences of "the" → 10 million objects with identical text/pos/lemma
# sys.getsizeof per object ≈ 200 bytes → ~2 GB just for "the"

The Solution - Intrinsic / Extrinsic Split

Flyweight separates:

  • Intrinsic state - what is the same across all occurrences: text, pos, lemma. Stored in a shared pool.
  • Extrinsic state - what varies per context: doc_id, position. Passed in by the caller, never stored in the flyweight.
from __future__ import annotations
import sys
from dataclasses import dataclass
from typing import ClassVar


@dataclass(frozen=True, slots=True)
class TokenFlyweight:
"""
Intrinsic state only. Shared across all occurrences.
frozen=True: immutable and hashable - safe for sharing.
slots=True: eliminates __dict__, cuts memory ~3×.
"""
text: str
pos: str
lemma: str

# Class-level pool: key → shared instance
_pool: ClassVar[dict[tuple[str, str, str], "TokenFlyweight"]] = {}

def __new__(cls, text: str, pos: str, lemma: str) -> "TokenFlyweight":
key = (text, pos, lemma)
if key not in cls._pool:
instance = object.__new__(cls)
cls._pool[key] = instance
return cls._pool[key] # always return the shared instance

@classmethod
def pool_size(cls) -> int:
return len(cls._pool)

@classmethod
def clear_pool(cls) -> None:
cls._pool.clear()


@dataclass
class TokenOccurrence:
"""Extrinsic state lives here - one per actual occurrence."""
flyweight: TokenFlyweight # shared reference - cheap
doc_id: int
sentence_idx: int
token_idx: int

@property
def text(self) -> str:
return self.flyweight.text

@property
def pos(self) -> str:
return self.flyweight.pos

@property
def lemma(self) -> str:
return self.flyweight.lemma


# ── Simulate corpus processing ────────────────────────────────────────────────
def process_corpus(
docs: list[list[tuple[str, str, str]]]
) -> list[list[TokenOccurrence]]:
result = []
for doc_id, tokens in enumerate(docs):
occurrences = []
for tok_idx, (text, pos, lemma) in enumerate(tokens):
fw = TokenFlyweight(text, pos, lemma) # pool lookup or creation
occ = TokenOccurrence(
flyweight=fw,
doc_id=doc_id,
sentence_idx=0,
token_idx=tok_idx,
)
occurrences.append(occ)
result.append(occurrences)
return result


corpus = [
[("the", "DT", "the"), ("cat", "NN", "cat"), ("sat", "VBD", "sit")],
[("the", "DT", "the"), ("dog", "NN", "dog"), ("sat", "VBD", "sit")],
[("the", "DT", "the"), ("cat", "NN", "cat"), ("ran", "VBD", "run")],
]

docs = process_corpus(corpus)

total_occurrences = sum(len(d) for d in docs)
unique_flyweights = TokenFlyweight.pool_size()

print(f"Total token occurrences: {total_occurrences}") # 9
print(f"Unique flyweight objects: {unique_flyweights}") # 5 (the, cat, sat, dog, ran)

# Verify sharing: two different "the" occurrences share the same object
t1 = docs[0][0] # "the" in doc 0
t2 = docs[2][0] # "the" in doc 2
print(f"Same flyweight? {t1.flyweight is t2.flyweight}") # True
print(f"Different context? {t1.doc_id != t2.doc_id}") # True

__slots__ Memory Savings

import sys

class WithDict:
def __init__(self, text, pos, lemma):
self.text = text
self.pos = pos
self.lemma = lemma

class WithSlots:
__slots__ = ("text", "pos", "lemma")
def __init__(self, text, pos, lemma):
self.text = text
self.pos = pos
self.lemma = lemma

a = WithDict("the", "DT", "the")
b = WithSlots("the", "DT", "the")

# sys.getsizeof does not include referenced string objects, but shows base object cost
print(f"With __dict__ (base + dict): {sys.getsizeof(a) + sys.getsizeof(a.__dict__)} bytes")
print(f"With __slots__: {sys.getsizeof(b)} bytes")
# Typical: 344 bytes vs 72 bytes - nearly 5× savings per object

Intrinsic vs Extrinsic State Reference

PropertyIntrinsicExtrinsic
DefinitionShared, context-independent dataContext-dependent data
Stored inThe flyweight (pool)The occurrence/client
MutabilityMust be immutableCan be mutable
ExamplesToken text, POS, lemmaDoc ID, sentence index, position
Python mechanismfrozen=True, __slots__Regular dataclass / dict

Embedding Cache as Flyweight

from __future__ import annotations
from functools import lru_cache
from typing import ClassVar


class EmbeddingFlyweight:
"""
Cache embeddings by text hash - same text always produces same embedding.
This is the Flyweight pattern using Python's built-in lru_cache.
"""
_cache: ClassVar[dict[str, list[float]]] = {}

@classmethod
def get_embedding(cls, text: str) -> list[float]:
if text not in cls._cache:
print(f"[EMBED] Computing new embedding for: {text[:40]}...")
# Real: cls._cache[text] = model.encode(text).tolist()
cls._cache[text] = [ord(c) / 1000 for c in text[:10]]
return cls._cache[text] # shared list - callers should not mutate

@classmethod
def cache_size(cls) -> int:
return len(cls._cache)


# Same text → same embedding object
e1 = EmbeddingFlyweight.get_embedding("attention is all you need")
e2 = EmbeddingFlyweight.get_embedding("attention is all you need")
print(f"Same object: {e1 is e2}") # True
print(f"Cache size: {EmbeddingFlyweight.cache_size()}")

Part 7 - Proxy

Virtual Proxy - Lazy Loading a Heavy ML Model

from __future__ import annotations
from abc import ABC, abstractmethod
from typing import Optional


class TextClassifier(ABC):
@abstractmethod
def predict(self, text: str) -> str: ...

@abstractmethod
def batch_predict(self, texts: list[str]) -> list[str]: ...


class HeavyBERTClassifier(TextClassifier):
"""Simulates a model that takes significant time/memory to load."""

def __init__(self) -> None:
print("[MODEL] Loading BERT weights... (30 seconds in production)")
# Real: self._model = AutoModelForSequenceClassification.from_pretrained(...)
self._model = "bert-base-uncased"
print("[MODEL] Ready.")

def predict(self, text: str) -> str:
return f"positive (confidence=0.94, model={self._model})"

def batch_predict(self, texts: list[str]) -> list[str]:
return [self.predict(t) for t in texts]


class LazyClassifierProxy(TextClassifier):
"""
Virtual proxy - defers model loading until the first actual call.
From the caller's perspective this IS a TextClassifier.
"""

def __init__(self, factory=HeavyBERTClassifier) -> None:
self._factory = factory
self._real: Optional[TextClassifier] = None

def _ensure_loaded(self) -> TextClassifier:
if self._real is None:
print("[PROXY] First request received - initialising model")
self._real = self._factory()
return self._real

def predict(self, text: str) -> str:
return self._ensure_loaded().predict(text)

def batch_predict(self, texts: list[str]) -> list[str]:
return self._ensure_loaded().batch_predict(texts)

@property
def is_loaded(self) -> bool:
return self._real is not None


# Application startup - instant, no model loaded yet
print("=== Application starting ===")
classifier: TextClassifier = LazyClassifierProxy()
print("Proxy created - model NOT yet loaded\n")

# Simulate: 5 seconds later, first real request
print("=== First inference request ===")
result = classifier.predict("This product is absolutely amazing!")
print(result)

Protection Proxy - Role-Based Database Access

from __future__ import annotations
from dataclasses import dataclass
from enum import Enum, auto


class Permission(Enum):
READ = auto()
WRITE = auto()
DELETE = auto()
ADMIN = auto()


@dataclass(frozen=True)
class User:
username: str
permissions: frozenset[Permission]

def can(self, perm: Permission) -> bool:
return perm in self.permissions


class Database(ABC):
@abstractmethod
def query(self, sql: str) -> list[dict]: ...

@abstractmethod
def execute(self, sql: str) -> int: ...

@abstractmethod
def drop_table(self, table: str) -> None: ...


class RealDatabase(Database):
def query(self, sql: str) -> list[dict]:
print(f"[DB] Query: {sql[:60]}")
return [{"id": 1, "name": "result_row"}]

def execute(self, sql: str) -> int:
print(f"[DB] Execute: {sql[:60]}")
return 1

def drop_table(self, table: str) -> None:
print(f"[DB] DROP TABLE {table}")


class ProtectionProxy(Database):
"""
Protection proxy - enforces role-based access control.
The real database never needs to know about permissions.
"""

def __init__(self, db: Database, user: User) -> None:
self._db = db
self._user = user

def _check(self, perm: Permission) -> None:
if not self._user.can(perm):
raise PermissionError(
f"User '{self._user.username}' requires {perm.name} permission"
)

def query(self, sql: str) -> list[dict]:
self._check(Permission.READ)
return self._db.query(sql)

def execute(self, sql: str) -> int:
self._check(Permission.WRITE)
return self._db.execute(sql)

def drop_table(self, table: str) -> None:
self._check(Permission.ADMIN)
self._db.drop_table(table)


# Admin user
admin = User("alice", frozenset({Permission.READ, Permission.WRITE, Permission.ADMIN}))
admin_db = ProtectionProxy(RealDatabase(), admin)
admin_db.query("SELECT * FROM users LIMIT 10")
admin_db.drop_table("stale_cache")

# Read-only analyst
analyst = User("bob", frozenset({Permission.READ}))
analyst_db = ProtectionProxy(RealDatabase(), analyst)
analyst_db.query("SELECT COUNT(*) FROM events")
try:
analyst_db.drop_table("events") # should raise
except PermissionError as e:
print(f"Blocked: {e}")

The __getattr__ Trap - Full Explanation

This brings us back to the surprising snippet that opened this lesson. Here is the complete picture:

class Service:
tag = "service-v1"

def __repr__(self):
return "Service(tag=service-v1)"

def __len__(self):
return 42

def work(self):
return "done"


class NaiveProxy:
def __init__(self, obj):
self._obj = obj

def __getattr__(self, name):
# Called ONLY when normal lookup fails on the instance
print(f" __getattr__ called for: {name!r}")
return getattr(self._obj, name)


proxy = NaiveProxy(Service())

# These DO go through __getattr__ (instance attributes not found on NaiveProxy)
print(proxy.work()) # __getattr__ called for 'work', returns "done"
print(proxy.tag) # __getattr__ called for 'tag', returns "service-v1"

# These DO NOT go through __getattr__ - Python looks up on type(proxy)
print(repr(proxy)) # NaiveProxy's __repr__ (inherited from object), NOT "Service(tag=service-v1)"
print(len(proxy)) # TypeError: object of type 'NaiveProxy' has no len()

Why? Python's data model specifies that implicit invocation of special methods (all the __dunder__ methods) bypasses normal instance lookup and goes directly to type(obj).__dunder__. This is an intentional CPython optimisation: if Python had to call __getattr__ for every len() or repr(), the overhead would be enormous. The consequence is that __getattr__ is never consulted for implicit dunder calls.

The fix - explicit dunder forwarding:

class TransparentProxy:
"""
A proxy that properly delegates both regular attributes and dunder methods.
Uses object.__setattr__ / object.__getattribute__ to avoid infinite recursion.
"""

def __init__(self, obj: object) -> None:
# Use object.__setattr__ to bypass our own __setattr__
object.__setattr__(self, "_obj", obj)

# Regular attribute access
def __getattr__(self, name: str):
return getattr(object.__getattribute__(self, "_obj"), name)

def __setattr__(self, name: str, value) -> None:
if name == "_obj":
object.__setattr__(self, name, value)
else:
setattr(object.__getattribute__(self, "_obj"), name, value)

# Explicitly forward every dunder you care about
def __repr__(self) -> str:
return repr(object.__getattribute__(self, "_obj"))

def __str__(self) -> str:
return str(object.__getattribute__(self, "_obj"))

def __len__(self) -> int:
return len(object.__getattribute__(self, "_obj"))

def __iter__(self):
return iter(object.__getattribute__(self, "_obj"))

def __contains__(self, item) -> bool:
return item in object.__getattribute__(self, "_obj")

def __bool__(self) -> bool:
return bool(object.__getattribute__(self, "_obj"))

def __eq__(self, other) -> bool:
return object.__getattribute__(self, "_obj") == other

def __hash__(self) -> int:
return hash(object.__getattribute__(self, "_obj"))


proxy = TransparentProxy(Service())
print(repr(proxy)) # "Service(tag=service-v1)" - correct now
print(len(proxy)) # 42 - correct now
print(proxy.work()) # "done" - still works

Note that isinstance(proxy, Service) still returns False - that requires either __class__ property forwarding or registering with abc.ABCMeta. For most proxy use cases, making all operations behave correctly is sufficient; exact type identity is rarely needed.

Using __getattribute__ for Full Interception

If you need to intercept every attribute access (including _obj itself), use __getattribute__. This is more powerful but also more dangerous:

class LoggingAllProxy:
"""Logs every single attribute access including our own internal ones."""

def __init__(self, obj):
# Must bypass __getattribute__ to store our internal state
object.__setattr__(self, "_obj", obj)
object.__setattr__(self, "_access_log", [])

def __getattribute__(self, name: str):
# __getattribute__ is called for EVERY attribute access, including _obj
if name.startswith("_"):
# Retrieve our own internals without recursing
return object.__getattribute__(self, name)

obj = object.__getattribute__(self, "_obj")
log = object.__getattribute__(self, "_access_log")
log.append(name)
return getattr(obj, name)

def access_log(self) -> list[str]:
return object.__getattribute__(self, "_access_log")


proxy = LoggingAllProxy(Service())
proxy.work()
proxy.tag
proxy.work()
print(proxy.access_log()) # ['work', 'tag', 'work']

Remote Proxy

A remote proxy makes a network-resident service look like a local object:

from __future__ import annotations
import json
import urllib.request
from abc import ABC, abstractmethod


class RecommendationEngine(ABC):
@abstractmethod
def recommend(self, user_id: str, top_k: int = 5) -> list[str]: ...


class RemoteRecommendationProxy(RecommendationEngine):
"""
Makes a remote microservice appear as a local object.
Callers never know they are making HTTP calls.
"""

def __init__(self, base_url: str, api_key: str) -> None:
self._base_url = base_url.rstrip("/")
self._api_key = api_key

def recommend(self, user_id: str, top_k: int = 5) -> list[str]:
url = f"{self._base_url}/v1/recommendations?user_id={user_id}&top_k={top_k}"
req = urllib.request.Request(
url,
headers={
"Authorization": f"Bearer {self._api_key}",
"Accept": "application/json",
},
)
# Real code would handle timeouts, retries, and error responses
with urllib.request.urlopen(req, timeout=5) as resp:
data = json.loads(resp.read().decode())
return data["items"]

Proxy Variants Reference

Proxy typeControlsReal-world example
VirtualObject creation / initialisationLazy ML model loading
ProtectionAccess based on permissionsRole-based DB / API access
RemoteNetwork locationMicroservice client stub
CachingRepeated expensive computationMemoised API calls
LoggingCall recordingAudit trail, observability
Smart ReferenceObject lifecycle (ref counting)Connection pool management

Structural Patterns - Quick Comparison

Pattern"I reach for this when..."Mechanism
AdapterThird-party interface doesn't match mineWrap + translate
BridgeTwo independent axes need to vary separatelyComposition across two hierarchies
CompositeI have recursive tree structuresUniform interface; composites delegate
DecoratorI want to layer behaviour without subclassingStacked wrappers with same interface
FacadeA subsystem has too many moving partsOne coordinating class
FlyweightI have millions of nearly-identical objectsShared pool of intrinsic state
ProxyI need to control access to an objectSurrogate with identical interface

Interview Q&A

Q1: What is the difference between the Adapter pattern and the Facade pattern?

Adapter translates one interface into another - it is a 1:1 shim, typically applied after the fact to reconcile a third-party API you cannot modify with a target interface your code already expects. The work is structural translation. Facade simplifies access to a collection of subsystem interfaces by providing a single, purpose-built front door. The work is complexity reduction. A Facade often contains multiple Adapters internally. The key signal: if you are bridging incompatibility, it is Adapter; if you are reducing complexity, it is Facade.

Q2: Python's @decorator syntax and the GoF Decorator pattern - are they the same thing?

They are related in spirit but not equivalent. Python's @decorator syntax wraps a callable at definition time, typically returning a new callable. It does not preserve the type of the object being wrapped, and it operates at function/method granularity. The GoF Decorator pattern wraps object instances at runtime, with both the decorator and the wrapped object implementing the same interface so they are interchangeable. This enables transparent stacking: LoggingDecorator(RetryDecorator(RateLimitDecorator(RealClient()))) is still usable wherever HTTPClient is expected. The key GoF property - type-preserving, stackable, runtime composition - is absent from Python's function decorator syntax, though you can implement the GoF pattern using Python's @ syntax (e.g., class decorators).

Q3: Why does __getattr__ not intercept dunder method lookups, and what is the correct fix?

Python resolves special methods (dunder methods like __repr__, __len__, __iter__) by looking them up on the type of the object, not the instance, and not through the normal attribute lookup chain. This bypasses __getattr__ entirely. The rationale is performance: implicit dunder resolution happens in the hot path of many operations, and going through __getattr__ for every len() call would be too slow. The correct fix is to explicitly define each dunder method on the proxy class and forward it to the wrapped object. For comprehensive proxies, __getattribute__ provides full interception but requires careful use of object.__getattribute__ to avoid infinite recursion when accessing your own proxy's attributes.

Q4: When would you use Bridge instead of just Strategy?

Both involve holding a reference to an interchangeable implementation object. The distinction is conceptual scope. Strategy addresses one dimension of variation: the algorithm used for a single operation. Bridge addresses two independent dimensions simultaneously - both the abstraction hierarchy and the implementation hierarchy can grow independently. If you have a Notifier that needs both a channel (Email/SMS/Push) and a format (Plain/HTML/JSON), and both dimensions will expand over time, Bridge names the intent precisely. If you only have one dimension of variation (say, just the sorting algorithm), Strategy is the right vocabulary. Bridge is also designed upfront; Strategy can be applied retroactively.

Q5: Explain Flyweight's intrinsic vs extrinsic state split and why intrinsic state must be immutable.

Intrinsic state is the data that is the same across every use of a given flyweight - the properties that define the flyweight's identity (token text, POS tag, lemma). It is stored inside the flyweight object and shared. Extrinsic state is data that varies by context - a token's position in a document, the document ID, the sentence index. This is never stored in the flyweight; it is passed by the caller at the point of use or stored in a separate occurrence object. Intrinsic state must be immutable because the same flyweight object is simultaneously referenced by potentially millions of callers. If any caller could mutate it, all callers would see the mutation. In Python, @dataclass(frozen=True) combined with __slots__=True enforces immutability and eliminates the per-instance __dict__ overhead - together they are the canonical Flyweight implementation.

© 2026 EngineersOfAI. All rights reserved.